HashMap是最常见使用最频繁的java集合容器之一,它用来存储键值对。
继承关系及结构
1 | public class HashMap<K,V> |
可以看出 HashMap 继承自AbstractMap,实现了Map,Cloneable,Serializable等接口
HashMap内部由hash数组(或者叫bucket数组)和链表组成结构,链表用来解决hash冲突,hash数组的每个元素都是一个单链表的头结点。
1 | public class HashMap<K,V> |
table为hash数组用来存储链表的头结点,theshhold为阈值,当size满足 size >= threshold 时需要对hashmap扩容,threshold = newCapacity * loadFactor,这里newCapacity为期望的新的容量大小。具体我们在扩容时说明。
Entry是HashMap存储元素的数据结构,它至少应该包含存储的键值对以及链表结构中的next节点引用
1 | static class Entry<K,V> implements Map.Entry<K,V> { |
除了上面说的三个元素,Entry还包含了hash信息,这里的hash值是通过key的hashcodef进行哈希后的值。hash函数为
1 | final int hash(Object k) { |
感兴趣的读者可以研究下这个hash方法,它能使得添加的元素均匀的分布在hash数组中。
构造方法
1 | public HashMap() { |
默认的情况下,HashMap的容量为16,加载因子为0.75 ,所以阈值 threshold = 16,初始情况并没有通过loadFactor计算。init的实现为空,可见在构造方法中并没有去构造table数组,只是设置了一些参数。初始化table数组的过程是在添加第一个元素的时候进行的。
添加元素
了解了HashMap的结构后,接下来就看看怎么添加元素到HashMap中,这是通过put方法来完成的
1 | public V put(K key, V value) { |
put过程分为下面几个步骤进行:
- 添加第一个元素的时,table还是空的数组,这时需要通过inflateTable来初始化数组
- 如果key值为null,使用putForNullKey添加元素
- 否则通过key值计算hash值,如果key值已经存在则更新数组,否则通过addEntry添加新的键值对到map中
下面我们具体分析上面的步骤,先看数组是如何初始化的
1 | private void inflateTable(int toSize) { |
这个方法会通过roundUpToPowerOf2调整toSize,toSize为我们要设置的大小,这里调整为2的幂次方大且大于等于toSize,默认的大小一开始为16,这里调整后同样为16,然后计算阈值,通过capacity * loadFactor,当然阈值不能大于MAXIMUM_CAPACITY + 1,这里我们就明白了再构造方法中并不计算threshold,因为这里会进行真正的计算。最后会通过initHashSeedAsNeeded生成一个hash seed ,这个seed在hash中会使用,我们不用关心了解即可。
然后我们看下key为null值时HashMap是如何处理的
1 | private V putForNullKey(V value) { |
可以看到当key值为null是,默认是加入table中索引为0的位置的,它的hash值也为0。如果在table[0]的链表中有key值为null的则更新value值即可,否则通过addEntry添加,最后一个参数0指定了添加到table的0索引指定的buket。
对于key值不为null的元素,首先对key进行哈希后得到hash值,通过hash值确认在table中的index位置,这个是通过indexfor完成的
1 | static int indexFor(int h, int length) { |
得到索引后,就可以通过遍历索引处的链表开始查找该元素,如果找到就更新value值,这里找到的条件为
1 | e.hash == hash && ((k = e.key) == key || key.equals(k)) |
即hash值和key值都要匹配,key值的匹配可能会通过equals进行匹配,这也是最常见的情况。
如果未找到key值对应的Entry,需要调用addEntry添加
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
如果添加元素时size值大于等于阈值且当前buket不为空则需要扩容,关于扩容我们后面再说,这里看如何为key-value创建Entry,在createEntry首先从table中取到对应的bucket,随后new一个新的Entry,同时将size加1.
1 | Entry(int h, K k, V v, Entry<K,V> n) { |
可以看出,新创建的Entry是作为链表的头结点的,而之前取出的e头结点在创建Entry时候赋值给新结点的next引用,这样就是每次添加新的元素都会添加到链表的头部。
删除元素
1 | public V remove(Object key) { |
删除元素的逻辑和添加的大体上是类似的,首先通过key值计算hash值,这里需要注意当key为null时,hash值为0,随后从table中取到对应的bucket,最后遍历bucket,找到满足条件的key值,从链表中移除,并将size减1.
扩容
扩容的时候是在addEntry中进行的,这样可以分摊扩容操作的代价,具体看代码
1 | if ((size >= threshold) && (null != table[bucketIndex])) { |
之前我们知道了table数组大小默认为16,阈值为16*0.75=12,也就是当map大小增加到12时就要增加容量了,这里的容量是针对table数组的。而size是map的元素个数。可以看到默认情况是扩充为原来容量的2倍。
1 |
|
Map的容量是有大小限制的,最大为MAXIMUM_CAPACITY默认为2^30,当容量已经是最大值时不允许再扩容了,否则根据新的容量大小创建新的Entry数组,同时使用transfer将旧table中的元素转移到新的table数组中,同时根据新的容量计算阈值threshold。
1 | void transfer(Entry[] newTable, boolean rehash) { |
transfer会将旧table中的元素转移到新的table中,转移过程可能需要对key进行重新hash。
注意事项
由于HashMap在操作bucket时需要先对key值进行hash,而hash过程中会通过key值的hashCode进行计算,所以在针对逻辑相等的对象不仅需要实现equals方法而且也应该实现hashCode方法以保证hashcode值在逻辑上相等,否则key会被映射到不同的bucket中导致查找失败。这个在Joshua Bloch《Effective java》一书中有所强调。